Skip to main content

media_pp\elements\sink\muxer/
segmented_mp4_muxer.rs

1use std::{
2    path::PathBuf,
3    sync::{Arc, Mutex},
4    time::{Duration, Instant},
5};
6
7use crate::pp_log::{PpLog, pp_info};
8use ffmpeg_next as ffmpeg;
9
10use super::mp4_muxer::{Mp4Muxer, Mp4MuxerError};
11use crate::{
12    buffer::MediaBuffer,
13    control::ControlMsg,
14    element::{Element, ElementType, Sink, element_pp_log},
15    error::Result,
16};
17
18/// How a [`SegmentedMp4Muxer`] decides a segment is done and it's time to
19/// cut to a new file.
20#[derive(Debug, Clone, Copy)]
21pub enum SegmentPolicy {
22    /// Roughly this long per segment. "Roughly" because the actual cut
23    /// only happens once this much time has elapsed *and* the video
24    /// track's own next packet is a keyframe (see
25    /// [`SegmentedMp4Muxer::open`]'s own docs) — a segment can run a bit
26    /// longer than requested if keyframes are sparse.
27    Duration(Duration),
28}
29
30/// One track's fixed description — everything [`SegmentedMp4Muxer::open`]
31/// needs to re-add it to a fresh [`Mp4Muxer`] on every rotation.
32/// `parameters` is cloned each time ([`Mp4Muxer::add_stream`] consumes its
33/// own copy); the original stays here as the template.
34struct StreamDef {
35    name: Arc<str>,
36    parameters: ffmpeg::codec::Parameters,
37    time_base: ffmpeg::Rational,
38    /// Whether this is the track [`SegmentGroup::maybe_rotate`] waits for
39    /// a keyframe on before actually cutting — see
40    /// [`SegmentedMp4Muxer::open`]'s own docs.
41    is_video: bool,
42}
43
44/// Builds a [`SegmentedMp4Muxer`] the same two-phase way as a plain
45/// [`Mp4Muxer`] (every track's shape must be known before the first byte
46/// is written) — `create` picks the rotation policy and how segments get
47/// named, `add_stream` registers each track exactly like
48/// [`Mp4Muxer::add_stream`], `open` writes the first segment's header and
49/// returns one [`Sink`] per track.
50///
51/// ```ignore
52/// let mut muxer = SegmentedMp4Muxer::create(
53///     SegmentPolicy::Duration(Duration::from_secs(600)),
54///     |index| PathBuf::from(format!("rec_{index:04}.mp4")),
55/// );
56/// muxer.add_stream("video", video_encoder.parameters(), video_time_base);
57/// muxer.add_stream("audio", audio_encoder.parameters(), audio_time_base);
58/// let mut sinks = muxer.open()?;
59/// ```
60pub struct SegmentedMp4Muxer {
61    policy: SegmentPolicy,
62    naming: Box<dyn FnMut(u64) -> PathBuf + Send>,
63    streams: Vec<StreamDef>,
64}
65
66impl SegmentedMp4Muxer {
67    /// `naming(index)` names each segment file, `index` starting at `0` —
68    /// called once up front for the first segment and again on every
69    /// rotation. Typically a closure building a path from a fixed
70    /// directory/prefix (e.g. `|i| dir.join(format!("rec_{i:04}.mp4"))`);
71    /// a timestamp-based scheme works just as well since `index` is only
72    /// ever used to *call* this, never to build the path itself.
73    pub fn create(
74        policy: SegmentPolicy,
75        naming: impl FnMut(u64) -> PathBuf + Send + 'static,
76    ) -> Self {
77        Self {
78            policy,
79            naming: Box::new(naming),
80            streams: Vec::new(),
81        }
82    }
83
84    /// Registers one more track every segment file will hold — same
85    /// contract as [`Mp4Muxer::add_stream`] (same order rules, same
86    /// `name`/`parameters`/`time_base` meaning), except this can't fail:
87    /// nothing here touches ffmpeg yet, it's only recorded for
88    /// [`SegmentedMp4Muxer::open`] (and every later rotation) to replay.
89    ///
90    /// Whichever stream's `parameters.medium()` is
91    /// [`ffmpeg::media::Type::Video`] (at most one is expected) becomes
92    /// the keyframe-gating track described in [`SegmentedMp4Muxer::open`]'s
93    /// own docs — no separate flag to pass.
94    pub fn add_stream(
95        &mut self,
96        name: impl Into<String>,
97        parameters: ffmpeg::codec::Parameters,
98        time_base: ffmpeg::Rational,
99    ) {
100        let is_video = parameters.medium() == ffmpeg::media::Type::Video;
101        self.streams.push(StreamDef {
102            name: name.into().into(),
103            parameters,
104            time_base,
105            is_video,
106        });
107    }
108
109    /// Writes the first segment's header and returns one [`Sink`] per
110    /// track, in the order [`SegmentedMp4Muxer::add_stream`] added them —
111    /// same shape as [`Mp4Muxer::open`]. All returned `Sink`s share one
112    /// rotation lock: a track's `consume` blocks while another track (on
113    /// its own thread) is mid-rotation, same tradeoff [`Mp4Muxer`]'s own
114    /// shared file lock already makes.
115    ///
116    /// **Rotation timing**: once a segment has run at least as long as the
117    /// configured [`SegmentPolicy`], the cut happens on the *video*
118    /// track's next keyframe (found via `add_stream`'s `parameters` — see
119    /// its own docs) — not immediately, and not on an arbitrary packet —
120    /// so every segment file is independently decodable from its own
121    /// first frame, the same way a real segment/HLS muxer cuts. A segment
122    /// can therefore run somewhat longer than requested if keyframes are
123    /// sparse; there's no hard cap. If no track's `parameters.medium()`
124    /// was `Video` (an audio-only recording), any packet on any track is
125    /// an equally valid cut point, so rotation happens as soon as the
126    /// policy is due.
127    ///
128    /// The final segment is finalized the same way a plain [`Mp4Muxer`]
129    /// finalizes its one file: once every track has reported `Eos` *or*
130    /// [`ControlMsg::Stop`] (see [`Mp4Muxer::open`]'s own docs) — a
131    /// rotation mid-recording reuses that exact mechanism to close the
132    /// outgoing segment before opening the next one.
133    pub fn open(mut self) -> Result<Vec<Box<dyn Sink>>> {
134        // Captured before `self.streams` moves into `GroupState` below —
135        // just the names, in order, for building each `SegmentedTrackSink`
136        // afterward.
137        let names: Vec<Arc<str>> = self.streams.iter().map(|s| s.name.clone()).collect();
138        let path = (self.naming)(0);
139        let current_sinks = open_segment(&self.streams, path)?;
140        let group = Arc::new(SegmentGroup {
141            policy: self.policy,
142            naming: Mutex::new(self.naming),
143            state: Mutex::new(GroupState {
144                streams: self.streams,
145                current_sinks,
146                segment_index: 0,
147                segment_started: Instant::now(),
148            }),
149        });
150        Ok(names
151            .into_iter()
152            .enumerate()
153            .map(|(index, name)| -> Box<dyn Sink> {
154                Box::new(SegmentedTrackSink {
155                    pp_log: element_pp_log(ElementType::SegmentedMp4Muxer, &name, None),
156                    name,
157                    track_index: index,
158                    group: group.clone(),
159                })
160            })
161            .collect())
162    }
163}
164
165fn open_segment(streams: &[StreamDef], path: PathBuf) -> Result<Vec<Box<dyn Sink>>> {
166    let mut muxer = Mp4Muxer::create(&path)?;
167    for stream in streams {
168        muxer.add_stream(
169            stream.name.to_string(),
170            stream.parameters.clone(),
171            stream.time_base,
172        )?;
173    }
174    muxer.open()
175}
176
177struct GroupState {
178    /// This group's fixed track descriptions — moved in here (rather than
179    /// sitting on [`SegmentGroup`] directly) purely so `Mutex<GroupState>`
180    /// covers it too: [`ffmpeg::codec::Parameters`] wraps a raw pointer and
181    /// isn't `Sync`, so a field of this type living outside any `Mutex`
182    /// would make `SegmentGroup` itself `!Sync` and unable to cross
183    /// threads inside the `Arc` every [`SegmentedTrackSink`] holds.
184    /// `Mutex<T>` only ever needs `T: Send` (already true here — see
185    /// `ffmpeg-next`'s own `unsafe impl Send for Parameters`), never
186    /// `T: Sync`, which is exactly what sidesteps that.
187    streams: Vec<StreamDef>,
188    /// The currently-open segment's own per-track sinks, in the same
189    /// order as `streams` — index-aligned with
190    /// [`SegmentedTrackSink::track_index`].
191    current_sinks: Vec<Box<dyn Sink>>,
192    segment_index: u64,
193    segment_started: Instant,
194}
195
196/// Shared between every [`SegmentedTrackSink`] [`SegmentedMp4Muxer::open`]
197/// hands out — one rotation lock around the whole group of tracks, so a
198/// rotation triggered by one track's packet arrival is atomic with respect
199/// to every other track (either all of them are still writing into the
200/// outgoing segment, or all of them are already writing into the new one —
201/// never a mix).
202struct SegmentGroup {
203    policy: SegmentPolicy,
204    naming: Mutex<Box<dyn FnMut(u64) -> PathBuf + Send>>,
205    state: Mutex<GroupState>,
206}
207
208impl SegmentGroup {
209    /// Called for every `Packet` on every track, before it's written.
210    /// Rotates first if this is the packet that should trigger it (see
211    /// [`SegmentedMp4Muxer::open`]'s own docs), then writes into whichever
212    /// segment is current by the time this returns.
213    fn consume_packet(
214        &self,
215        track_index: usize,
216        packet: Arc<ffmpeg::Packet>,
217        pp_log: &PpLog,
218    ) -> Result<()> {
219        let mut state = self.state.lock().unwrap();
220        let SegmentPolicy::Duration(due_after) = self.policy;
221        let mut rotated_to = None;
222        if state.segment_started.elapsed() >= due_after {
223            let has_video = state.streams.iter().any(|s| s.is_video);
224            let this_is_video = state.streams[track_index].is_video;
225            let should_cut = if has_video {
226                this_is_video && packet.is_key()
227            } else {
228                true
229            };
230            if should_cut {
231                for sink in state.current_sinks.iter_mut() {
232                    sink.control(ControlMsg::Stop)?;
233                }
234                let index = state.segment_index + 1;
235                let path = (self.naming.lock().unwrap())(index);
236                state.current_sinks = open_segment(&state.streams, path)?;
237                state.segment_index = index;
238                state.segment_started = Instant::now();
239                rotated_to = Some(index);
240            }
241        }
242        let result = state.current_sinks[track_index].consume(MediaBuffer::Packet(packet));
243        // Formatting and emitting happen off the group lock: every track's
244        // `consume_packet` contends for it, so nothing that isn't required
245        // to be serialized with the rotation belongs inside it.
246        drop(state);
247        if let Some(index) = rotated_to {
248            pp_info!(pp_log: pp_log, "rotated segment_index={index}");
249        }
250        result
251    }
252
253    /// One track's own natural `Eos` — forwarded into whatever segment is
254    /// current, same as [`SegmentGroup::consume_packet`] but without a
255    /// rotation check (ending is ending, not a cut point).
256    fn finish_eos(&self, track_index: usize) -> Result<()> {
257        let mut state = self.state.lock().unwrap();
258        state.current_sinks[track_index].consume(MediaBuffer::Eos)
259    }
260
261    /// One track's own [`ControlMsg::Stop`] — same as
262    /// [`SegmentGroup::finish_eos`], just forwarded as `Stop` instead of
263    /// `Eos` (matters for `Mp4Muxer`'s own docs on `Stop` finalizing a
264    /// container even though it otherwise means "abandon, don't drain").
265    fn finish_stop(&self, track_index: usize) -> Result<()> {
266        let mut state = self.state.lock().unwrap();
267        state.current_sinks[track_index].control(ControlMsg::Stop)
268    }
269}
270
271/// One track's own [`Sink`] — a lightweight handle sharing a
272/// [`SegmentGroup`] with every other track [`SegmentedMp4Muxer::open`]
273/// returned alongside it.
274struct SegmentedTrackSink {
275    pp_log: PpLog,
276    name: Arc<str>,
277    track_index: usize,
278    group: Arc<SegmentGroup>,
279}
280
281impl Element for SegmentedTrackSink {
282    fn name(&self) -> Arc<str> {
283        self.name.clone()
284    }
285
286    fn element_type(&self) -> ElementType {
287        ElementType::SegmentedMp4Muxer
288    }
289
290    fn pp_log(&self) -> &PpLog {
291        &self.pp_log
292    }
293
294    fn pp_log_mut(&mut self) -> &mut PpLog {
295        &mut self.pp_log
296    }
297}
298
299impl Sink for SegmentedTrackSink {
300    fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
301        match buf {
302            MediaBuffer::Packet(packet) => {
303                self.group
304                    .consume_packet(self.track_index, packet, &self.pp_log)
305            }
306            MediaBuffer::Eos => self.group.finish_eos(self.track_index),
307            // The `Mp4Muxer` each rotated segment wraps already rejects
308            // this — matching its own `Mp4MuxerStreamSink::consume` here
309            // instead of silently no-op'ing keeps that protection visible
310            // through the rotation wrapper instead of swallowing it.
311            other => Err(Mp4MuxerError::UnsupportedBuffer(other.kind()).into()),
312        }
313    }
314
315    fn control(&mut self, msg: ControlMsg) -> Result<()> {
316        if msg == ControlMsg::Stop {
317            self.group.finish_stop(self.track_index)?;
318        }
319        Ok(())
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::{
327        elements::{SwEncoder, SwEncoderOptions, TestVideoOptions, TestVideoSource, VideoCodec},
328        pipeline::Pipeline,
329    };
330
331    /// Drives a real `TestVideoSource -> SwEncoder -> SegmentedMp4Muxer`
332    /// chain for a few real seconds with a short rotation policy, then
333    /// checks every segment file it produced: each one has to be a real,
334    /// independently-readable `.mp4` whose very first packet is a
335    /// keyframe — proof the cut actually waited for one (see
336    /// `SegmentedMp4Muxer::open`'s own docs on why that matters: cutting
337    /// on an arbitrary packet would leave a segment starting mid-GOP,
338    /// undecodable from its own frame 0).
339    #[test]
340    fn rotates_into_multiple_valid_keyframe_aligned_segments() {
341        let video_options = TestVideoOptions {
342            width: 160,
343            height: 120,
344            framerate: ffmpeg::Rational::new(15, 1),
345        };
346        let video_source = TestVideoSource::new("video", video_options);
347        let time_base = video_source.time_base();
348        let encoder = SwEncoder::new(
349            "encoder",
350            SwEncoderOptions {
351                codec: VideoCodec::OpenH264,
352                width: video_options.width,
353                height: video_options.height,
354                time_base,
355                frame_rate: video_options.framerate,
356                bit_rate: 200_000,
357                // Short on purpose (~0.5s @ 15fps) — this test needs
358                // several real keyframes to show up quickly, not the
359                // ~2s default every other caller uses.
360                gop_size: 8,
361            },
362        )
363        .expect("openh264 encoder must be available");
364
365        let dir = std::env::temp_dir();
366        let prefix = format!("segmented_mp4_test_{}", std::process::id());
367        let paths: Arc<Mutex<Vec<PathBuf>>> = Arc::new(Mutex::new(Vec::new()));
368        let recorded_paths = paths.clone();
369
370        let mut muxer = SegmentedMp4Muxer::create(
371            SegmentPolicy::Duration(Duration::from_millis(300)),
372            move |index| {
373                let path = dir.join(format!("{prefix}_{index:03}.mp4"));
374                recorded_paths.lock().unwrap().push(path.clone());
375                path
376            },
377        );
378        muxer.add_stream("video", encoder.parameters(), time_base);
379        let mut sinks = muxer.open().expect("open must succeed");
380        let sink = sinks.pop().expect("exactly one stream was added");
381
382        let pipeline = Pipeline::new("segmented-test", video_source, |source, ctx| {
383            let branch = ctx.branch().pipe(encoder).to(sink)?;
384            ctx.attach(source, 0, branch)?;
385            Ok(())
386        })
387        .expect("test pipeline wiring must succeed");
388        pipeline.run();
389        std::thread::sleep(Duration::from_secs(3));
390        pipeline.stop();
391        pipeline.bus().log_events();
392
393        let paths = paths.lock().unwrap().clone();
394        assert!(
395            paths.len() >= 2,
396            "expected at least 2 segments, got {}: {paths:?}",
397            paths.len()
398        );
399
400        for path in &paths {
401            let mut input = ffmpeg::format::input(path)
402                .unwrap_or_else(|error| panic!("segment {path:?} must be readable: {error}"));
403            assert_eq!(
404                input.streams().count(),
405                1,
406                "segment {path:?} should have exactly one stream"
407            );
408            let mut packet = ffmpeg::Packet::empty();
409            if packet.read(&mut input).is_err() {
410                panic!("segment {path:?} has no packets at all");
411            }
412            assert!(
413                packet.is_key(),
414                "segment {path:?}'s first packet must be a keyframe"
415            );
416        }
417
418        for path in &paths {
419            std::fs::remove_file(path).ok();
420        }
421    }
422
423    /// A misrouted `Audio` buffer used to be silently dropped by
424    /// `SegmentedTrackSink::consume`. The `Mp4Muxer` each segment wraps
425    /// already rejects this via `Mp4MuxerError::UnsupportedBuffer` — the
426    /// rotation wrapper must surface that instead of swallowing it.
427    #[test]
428    fn rejects_a_buffer_type_the_wrapped_mp4_muxer_does_not_accept() {
429        let video_options = TestVideoOptions {
430            width: 160,
431            height: 120,
432            framerate: ffmpeg::Rational::new(15, 1),
433        };
434        let video_source = TestVideoSource::new("video", video_options);
435        let time_base = video_source.time_base();
436        let encoder = SwEncoder::new(
437            "encoder",
438            SwEncoderOptions {
439                codec: VideoCodec::OpenH264,
440                width: video_options.width,
441                height: video_options.height,
442                time_base,
443                frame_rate: video_options.framerate,
444                bit_rate: 200_000,
445                gop_size: 8,
446            },
447        )
448        .expect("openh264 encoder must be available");
449
450        let path = std::env::temp_dir().join(format!(
451            "segmented_mp4_reject_test_{}.mp4",
452            std::process::id()
453        ));
454        let recorded_path = path.clone();
455        let mut muxer = SegmentedMp4Muxer::create(
456            SegmentPolicy::Duration(Duration::from_secs(3600)),
457            move |_index| recorded_path.clone(),
458        );
459        muxer.add_stream("video", encoder.parameters(), time_base);
460        let mut sinks = muxer.open().expect("open must succeed");
461        let mut sink = sinks.pop().expect("exactly one stream was added");
462
463        let error = sink
464            .consume(MediaBuffer::Audio(Arc::new(ffmpeg::frame::Audio::empty())))
465            .expect_err("an Audio buffer must be rejected, not silently dropped");
466        assert!(
467            matches!(
468                error,
469                crate::error::Error::Mp4MuxerError(Mp4MuxerError::UnsupportedBuffer("Audio"))
470            ),
471            "unexpected error: {error:?}"
472        );
473
474        drop(sink);
475        std::fs::remove_file(&path).ok();
476    }
477
478    /// Regression test against a leaked file handle on the *previous*
479    /// segment specifically: a rotation has to fully close (write the
480    /// trailer, drop the underlying `Mp4Muxer` for that segment) the
481    /// outgoing file the moment it cuts — not defer that until the whole
482    /// recording later stops. Proven by reading the first segment back
483    /// *while the pipeline is still running* (recording into the second
484    /// one) — if `SegmentGroup::consume_packet` kept anything from the old
485    /// segment alive past the cut, this would find it still unreadable
486    /// (or, on Windows, fail to even open for read at all due to a
487    /// lingering write lock).
488    #[test]
489    fn old_segment_is_released_immediately_not_deferred_until_the_whole_recording_stops() {
490        let video_options = TestVideoOptions {
491            width: 160,
492            height: 120,
493            framerate: ffmpeg::Rational::new(15, 1),
494        };
495        let video_source = TestVideoSource::new("video", video_options);
496        let time_base = video_source.time_base();
497        let encoder = SwEncoder::new(
498            "encoder",
499            SwEncoderOptions {
500                codec: VideoCodec::OpenH264,
501                width: video_options.width,
502                height: video_options.height,
503                time_base,
504                frame_rate: video_options.framerate,
505                bit_rate: 200_000,
506                gop_size: 8, // ~0.5s @ 15fps — see the other test's own note
507            },
508        )
509        .expect("openh264 encoder must be available");
510
511        let dir = std::env::temp_dir();
512        let prefix = format!("segmented_mp4_release_test_{}", std::process::id());
513        let paths: Arc<Mutex<Vec<PathBuf>>> = Arc::new(Mutex::new(Vec::new()));
514        let recorded_paths = paths.clone();
515
516        let mut muxer = SegmentedMp4Muxer::create(
517            SegmentPolicy::Duration(Duration::from_millis(300)),
518            move |index| {
519                let path = dir.join(format!("{prefix}_{index:03}.mp4"));
520                recorded_paths.lock().unwrap().push(path.clone());
521                path
522            },
523        );
524        muxer.add_stream("video", encoder.parameters(), time_base);
525        let mut sinks = muxer.open().expect("open must succeed");
526        let sink = sinks.pop().expect("exactly one stream was added");
527
528        let pipeline = Pipeline::new("segmented-release-test", video_source, |source, ctx| {
529            let branch = ctx.branch().pipe(encoder).to(sink)?;
530            ctx.attach(source, 0, branch)?;
531            Ok(())
532        })
533        .expect("test pipeline wiring must succeed");
534        pipeline.run();
535
536        // Wait (bounded) for at least one rotation — the pipeline is
537        // deliberately still running past this point.
538        let waited = Instant::now();
539        loop {
540            if paths.lock().unwrap().len() >= 2 {
541                break;
542            }
543            assert!(
544                waited.elapsed() < Duration::from_secs(5),
545                "no rotation happened within 5s"
546            );
547            std::thread::sleep(Duration::from_millis(50));
548        }
549
550        let first_segment = paths.lock().unwrap()[0].clone();
551        let mut input = ffmpeg::format::input(&first_segment).unwrap_or_else(|error| {
552            panic!("segment 0 must already be readable while still recording segment 1: {error}")
553        });
554        let mut packet = ffmpeg::Packet::empty();
555        assert!(
556            packet.read(&mut input).is_ok(),
557            "segment 0 must have packets"
558        );
559        drop(input);
560
561        pipeline.stop();
562        pipeline.bus().log_events();
563
564        for path in paths.lock().unwrap().iter() {
565            std::fs::remove_file(path).ok();
566        }
567    }
568}